nextTick 原理及使用

2023/04/03 15:20


nextTick 作用

vue 官网对 nextTick 的介绍为 等待下一次 DOM 更新刷新的工具方法。首先什么是 DOM 更新。

Dom 更新

document.querySelector('div').innerHTML = 'Dom 更新'
console.log(document.querySelector('div').innerHTML) // 会输出 Dom 更新

Dom 更新需要先对 Dom 对象上的属性做修改,例如修改某个节点 innerHTML,这一步是同步的,然后浏览器会异步更新 DOM。但对于 Vue 而言,响应式更新帮我们隐去了这一步。

<template>
  <div class="aka">{{a}}</div>
  <button @click="fn">click</button>
</template>

<script setup>
  const a = ref('')

  function fn() {
    a.value = 'Dom 更新'
  }
</script>

每次点击 button 更改 a.value 就会触发 Dom 更新。但如上文更改 Dom 属性是同步的,倘若代码是这样的

<template>
  <div>{{a}}|{{b}}</div>
  <button @click="fn">click</button>
</template>

<script setup>
  const a = ref('')
  const b = ref('')

  function fn() {
    for (let i = 1; i <= 1000; i++) {
      a.value = `Dom 更新, ${i}`
      b.value = `Dom 更新, ${i}`
    }
  }
</script>

意味着需要修改 Dom 属性 1000 次,这显然极其浪费性能而且是无意义的,因为对于浏览器来说在执行异步的渲染任务前即便修改了 1000 次 Dom,也只会渲染 Dom 更新, 1000 这最后一次修改。所以 Vue 内部也采用了一样的办法,异步的更新 Dom 属性。

Vue 内部的异步更新

当模板上这样展示 {{ a }} ,即在编译好的渲染函数中访问响应式变量 a, 会为 a 添加一个 dep,当 a 变更后重新执行此 dep。在 源码 中

const update: SchedulerJob = () => effect.run()
update.id = instance.uid
// create reactive effect for rendering
const effect = (instance.effect = new ReactiveEffect(
  componentUpdateFn,
  () => queueJob(update),
  instance.scope, // track it in component's effect scope
))

先通过 new ReactiveEffect 生成一个 effect ,传入的第一个参数会在 ReactiveEffect 内部保存为一个 fn,然后 ReactiveEffect 返回一个 run 函数,执行 effect.run(),就会执行内部的 this.fn() 即 componentUpdateFn,把这个 effect 当做 dep 添加到响应式变量 a 的 deps 中,a 变更后再执行 dep.run() 。但这仍然是同步更改,a.value 被赋值 1000 次,就会执行 1000 次 componentUpdateFn 。所以 ReactiveEffect 可以传入第二个变量,允许你自定义怎么调用 effect.run(),在内部保存为 scheduler。源码中将 effect.run() 传入到 queueJob 函数,而 queueJob 函数就可以异步的延迟调用 effect.run()。源码:

export function queueJob(job: SchedulerJob) {
  if (
    !queue.length ||
    !queue.includes(job, isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex)
  ) {
    if (job.id == null) {
      queue.push(job)
    } else {
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
    queueFlush()
  }
}

function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

首先 effect.run() 会被当做一个 job 传入到 queueJob 函数,如果 a.value 变更后,传入的 job 不在 queue 中,那么此 job 就会被添加到 queue 并且调用 queueFlush 函数,这样即便触发了 1000 次更新,只要 queue 包含这个更新,只执行一次就可以了,并且同一任务内 b.value 变更也不会再存入一个 job,因为渲染函数存入 a b 的 dep 是相同的。其实从这里就可以解决多次触发的问题,但是同步更新 Dom 可能会引起回流或者重绘,因此 Vue 通过 resolvedPromise 创建了一个微任务,将所有的更新推迟到微任务中,一次性更新 Dom 。

nextTick

讲到这里,nextTick 的作用就出来了,当 Vue 在微任务中修改 Dom 属性后,在当前任务中是拿不到被修改后的 Dom 属性的。

<template>
  <div>{{a}}</div>
  <button @click="fn">click</button>
</template>

<script setup>
  const a = ref('')

  function fn() {
    for (let i = 1; i <= 1000; i++) {
      a.value = `Dom 更新, ${i}`
    }

    console.log(document.querySelector('div').innerHTML) // 输出空字符串
    Promise.resolve().then(() => {
      console.log(document.querySelector('div').innerHTML) // 输出 Dom 更新, 1
    })
  }
</script>

nextTick 也是对 Promise.resolve 的封装

const resolvedPromise = /* #__PURE__ */ Promise.resolve() as Promise<any>
export function nextTick<T = void>(this: T, fn?: (this: T) => void): Promise<void> {
  const p = currentFlushPromise || resolvedPromise

  return fn ? p.then(this ? fn.bind(this) : fn) : p
}

currentFlushPromise 不为空的情况下,优先使用 currentFlushPromise。

function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

currentFlushPromise 被赋值为一个 promise,当 flushJobs 函数执行完后,也就是 Dom 属性更新完后就会执行 nextTick 的回调,而在这个回调函数中就可以得到被修改后的 Dom 属性值。